Skip to main content

Creating Custom Toolbox Images for Fedora Silverblue

Various instruments hanging on wooden board in garage
Photo by Andrea Piacquadio on Pexels

Toolbox is a fantastic way to make immutable desktop Linux distributions actually usable.

The standard Fedora Silverblue workflow involves running GUI applications using Flatpak, and creating mutable toolbox containers for day-to-day CLI tasks. While this is a fine approach, in my opinion, it doesn’t fully embrace the immutable paradigm shift.

When I first adopted silverblue, I followed the pattern of manually installing and configuring my personal toolbox container as a “pet”. This quickly becomes irritating in practice, especially when dealing with major version changes, and multiple toolbox containers. A better approach is to treat the environment itself like code.

Using a custom image with toolbox #

Toolbox supports creating a new container with a user supplied image, by passing the --image flag when creating a new container:

toolbox create --image <image-name>:<tag>

Since toolbox builds on Podman and other OCI technologies, you can build a toolbox compatible image from a standard Containerfile. This facilitates creating a custom environment without repetitive manual configuration.

Creating your own Containerfile #

Here is an example of a simple Containerfile that builds on the official toolbox image, installing some desired packages, and setting an environment variable:

Containerfile

FROM registry.fedoraproject.org/fedora-toolbox:38

ARG NAME=toolbox

LABEL name="$NAME" \
      summary="Fedora toolbox container" \
      maintainer="William Vandervalk"

ENV EDITOR=nvim

# Install packages
RUN dnf -y upgrade \
  && dnf -y install \
  tmux \
  nnn \
  lsd \
  tldr \
  python3-pip \
  nodejs \
  gcc \
  gcc-c++ \
  ripgrep \
  fd-find \
  neovim \
  && dnf clean all

Building an image #

Local #

To build an image (and assign it the name toolbox):

podman build -t toolbox -f /path/to/Containerfile

Now pass this image to toolbox and create a new container (also named toolbox):

toolbox create -i toolbox toolbox

Helper script #

To make things easier, a simple helper script placed alongside the Containerfile is useful for rebuilding locally:

build.sh

#!/bin/bash

# Set desired name via CLI argument, but default to "toolbox"
name="${1:-toolbox}"

echo "Cleaning existing image and container(s) if any exist"
toolbox rmi "$name" --force &> /dev/null

cd $(dirname "${BASH_SOURCE[0]}")

echo "Building image"
podman build -t "$name" -f Containerfile

echo "Creating toolbox"
toolbox create -i "$name" "$name"

To build a clean image and create a toolbox container (named toolbox by default) ensure your build script is executable, then run:

./build.sh

Or if you would like to specify a different name:

./build.sh toolbox-custom-name

Now, if you would like to modify your custom toolbox, rather than making ad-hoc changes, simply modify your Containerfile and rebuild.

This is a simple but powerful method for keeping your environment fully reproducible. For example, when Fedora has a new release, simply increment the version tag in the FROM line of your Containerfile, rebuild, and you will have an up to date toolbox including all of your customizations.

Building locally is simple and convenient, but an interesting alternative is to build and publish your image to a container registry using a CI/CD pipeline like Github Actions.

GitHub #

Create repository #

First create a new repository on GitHub, making sure to add your Containerfile.

Note that in order to publish to GitHub Packages using a free account, your repository must be set to public. Be careful to not commit any secrets to your repository.

Image signing #

To establish a trustworthy build chain, image signing via cosign is recommended.

Ensure that cosign is installed on your local workstation. Since it is written in go, downloading and running the single binary is easy.

Now create a key pair (password optional, but recommended):

cosign generate-key-pair
Be careful not to commit your private key cosign.key to your public repository.

Once you have generated a key pair, using the GitHub web interface, add the key as a secret to your repository, naming it COSIGN_PRIVATE_KEY.

If you created your key with a password, add it as a separate secret, named COSIGN_PASSWORD.

Add workflow #

A custom workflow using GitHub Actions can be used to build, sign and publish container images to GitHub Packages whenever your Containerfile is modified via push or pull.

Make sure you have set up image signing, then add the following build workflow to your repository, setting the desired image name using the environment variable IMAGE_NAME:

.github/workflows/build.yml

name: build
on:
  workflow_dispatch:
  push:
    branches:
      - main
    paths:
      - Containerfile
  pull_request:
    branches:
      - main
    paths:
      - Containerfile

env:
  IMAGE_NAME: toolbox
  REGISTRY: ghcr.io

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@v3

      - name: Build image
        id: build-image
        uses: redhat-actions/buildah-build@v2
        with:
          image: ${{ env.IMAGE_NAME }}
          tags: latest ${{ github.sha }}
          containerfiles: |
            ./Containerfile            

      - name: Log in to the GitHub Container registry
        uses: redhat-actions/podman-login@v1
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Push to GitHub Container Repository
        id: push-to-ghcr
        uses: redhat-actions/push-to-registry@v2
        with:
          image: ${{ steps.build-image.outputs.image }}
          tags: ${{ steps.build-image.outputs.tags }}
          registry: ${{ env.REGISTRY }}/${{ github.actor }}

      - name: Install Cosign
        uses: sigstore/[email protected]

      - name: Sign container image
        run: |
          cosign sign -y --key env://COSIGN_PRIVATE_KEY ${{ env.REGISTRY }}/${{ github.actor }}/${{ env.IMAGE_NAME }}@${TAGS}          
        env:
          TAGS: ${{ steps.push-to-ghcr.outputs.digest }}
          COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
          COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}

Adding custom binaries to image #

The example Containerfile demonstrates how to add repository packages to your image, but in many cases software is not packaged in the official repositories. Here is an example building on the previous example, including cosign from GitHub Releases:

FROM registry.fedoraproject.org/fedora-toolbox:38

ARG NAME=toolbox
ARG BIN_DIR=/usr/local/bin
ARG COMPLETIONS_DIR=/usr/local/share/bash-completion/completions

ARG COSIGN_VERSION=v2.0.2

LABEL name="$NAME" \
      summary="Fedora toolbox container" \
      maintainer="William Vandervalk"

ENV EDITOR=nvim

# Install packages
RUN dnf -y upgrade \
  && dnf -y install \
  tmux \
  nnn \
  lsd \
  tldr \
  python3-pip \
  nodejs \
  gcc \
  gcc-c++ \
  ripgrep \
  fd-find \
  neovim \
  && dnf clean all

# Create bash-completion dir
RUN mkdir -p "${COMPLETIONS_DIR}"

# Install cosign
RUN curl -Lo "${BIN_DIR}/cosign" \
  "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64" \
  && chmod +x "${BIN_DIR}/cosign" \
  && cosign completion bash > "${COMPLETIONS_DIR}/cosign"

Note the added build arguments BIN_DIR, COMPLETIONS_DIR and COSIGN_VERSION.

Managing dependencies with Renovate #

Regardless of which build method you use (local or GitHub Actions workflow), renovate is a fantastic way to keep your toolbox Containerfile up to date. In the previous example, cosign was included using a binary downloaded from GitHub Releases. To configure renovate so that it will monitor this, add the following renovate configuration to your repository:

.github/renovate.json

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:base"],
  "regexManagers": [
    {
      "fileMatch": ["(^|/|\\.)Containerfile$", "(^|/)Containerfile[^/]*$"],
      "matchStrings": [
        "# renovate: datasource=(?<datasource>[a-z-]+?) depName=(?<depName>[^\\s]+?)(?: (lookupName|packageName)=(?<packageName>[^\\s]+?))?(?: versioning=(?<versioning>[^\\s]+?))?(?: registryUrl=(?<registryUrl>[^\\s]+?))?\\s(?:ENV|ARG) .+?_VERSION[ =]\"?(?<currentValue>.+?)\"?\\s"
      ]
    }
  ]
}
Note that if you are using renovate as a GitHub App, you must allow it access to your toolbox repository.

Then add an instructive comment to your Containerfile, immediately preceding the version tag telling renovate where to look for new versions:

# renovate: datasource=github-releases depName=sigstore/cosign
ARG COSIGN_VERSION=v2.0.2

Now, whenever a new version of cosign is published, renovate will create a pull request automatically. If a build workflow is configured, a new toolbox image should be built automatically after merging.

This pattern can be repeated for any number of dependencies relying on GitHub Releases.

Note that you can still benefit from renovate, even if you do your building locally. As long as you have a GitHub repository hosting your Containerfile (even a private one) renovate can be set up to monitor for updates.